經過了這段時間的練習與學習,相信大家應該越來越能體會 Angular 表單的強大與便利。
不過既然 Angular 表單這麼好用,如果能讓自己做的 Component 也像 Angular 表單那樣一般使用該有多好?
因此,今天想要跟大家分享的是 ─ 如何自訂表單元件。
大家跟我一起想像一下,假設我們今天需要做一個管理平台,在這個管理平台裡,會有很多地方都會需要用到我們昨天做的 DateRangeComponent
,但不一定會是在同一個表單裡,只是剛好也需要 startDate
與 endDate
這兩個欄位,而且畫面與欄位驗證的規則也都是一樣。
例如:
A 頁面是一個查詢訂單系統, B 頁面是查詢會員系統,雖然這兩個頁面的查詢條件可能都不太一樣,但恰好都可以根據起迄日來查詢相應的資料。
這時,我們很有可能就會將我們做好的 DateRangeComponent
做成表單元件,讓 A 跟 B 在使用它的時候,就像使用一般的表單元件一樣輕鬆、自然。
那究竟要怎麼做呢?
首先要介紹給大家認識的是 ControlValueAccessor
,它是個 Interface
,而它定義了以下四個函式:
interface ControlValueAccessor {
writeValue(obj: any): void
registerOnChange(fn: any): void
registerOnTouched(fn: any): void
setDisabledState(isDisabled: boolean)?: void
}
writeValue(obj: any): void
─ 表單控件想要將值寫入時,會呼叫此函式registerOnChange(fn: any): void
─ 表單控件初始化時會呼叫此函式,並傳入一個回呼函式,讓實作此介面的類別在其值有變動時,使用該回呼函式並傳入欲變動的值
registerOnTouched(fn: any): void
─ 表單控件初始化時會呼叫此函式,並傳入一個回呼函式,讓實作此介面的類別在失去焦點時,使用該回呼函式以通知表單控件
setDisabledState(isDisabled: boolean)?: void
─ 當表單控件的狀態變成 DISABLED
抑或是從 DISABLED
改變成其他狀態時,會呼叫此函式以通知實作此介面的類別雖然我覺得我說的滿清楚的,但大家應該還是覺得很模糊,對吧?
不要緊,我只是先讓大家有個印象,待會實作時大家就會更加理解了。
首先,我們需要另一個 Component 來用我們昨天做好的 DateRangeComponent
,像這樣:
<form *ngIf="formGroup" [formGroup]="formGroup">
<app-date-range formControlName="dateRange"></app-date-range>
</form>
然後在 Component 的 .ts
裡準備好 FormGroup
,像這樣:
export class ReactiveFormsDateRangeComponent implements OnInit {
formGroup: FormGroup | undefined;
constructor(private formBuilder: FormBuilder) { }
ngOnInit(): void {
this.formGroup = this.formBuilder.group({ dateRange: '' });
}
}
接著打開昨天做的 DateRangeComponent
,並在 implements
的後方加上 ControlValueAccessor
,像這樣:
export class DateRangeComponent implements OnInit, ControlValueAccessor {
// ...
}
這時你應該會發現 DateRangeComponent
出現了一條紅色毛毛蟲,當你把滑鼠游標移到上面的時候,它說:
這是因為我們為 DateRangeComponent
加上實作 ControlValueAccessor
的宣告後,編輯器提醒我們要記得實作 ControlValueAccessor
的四個函式,才符合該介面的定義。
這就像是我們如果想要 Cosplay 鋼鐵人,但我什麼盔甲都沒穿就說自己是鋼鐵人,別人只會覺得滿臉問號。
但只要我們戴上了頭盔,別人就會知道你在扮演鋼鐵人。
所以我們就在 DateRangeComponent
裡加上以下四個函式:
export class DateRangeComponent implements OnInit, ControlValueAccessor {
// ...
writeValue(obj: any): void {
console.log('writeValue', obj);
}
registerOnChange(fn: any): void {
console.log('registerOnChange', fn);
}
registerOnTouched(fn: any): void {
console.log('registerOnTouched', fn);
}
setDisabledState(isDisabled: boolean): void {
console.log('setDisabledState', isDisabled);
}
}
接下來,我們需要在 DateRangeComponent
的 MetaData 裡的 providers
裡加入一些設定,像這樣:
@Component({
selector: 'app-date-range',
templateUrl: './date-range.component.html',
styleUrls: ['./date-range.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateRangeComponent),
multi: true
}
]
})
export class DateRangeComponent implements OnInit, ControlValueAccessor {
// ...
}
我們之前其實也曾經在第二十五天的文章 ─ 測試進階技巧 - DI 抽換裡用過類似的技巧。
簡單來說,這個設定是為了讓表單可以透過 NG_VALUE_ACCESSOR
這個 InjectionToken 取得我們這個實作了 ControlValueAccessor
介面的 DateRangeComponent
實體。
想知道什麼是 InjectionToken 的朋友,可以參考 Mike 的 [Angular 大師之路] Day 23 - 認識 InjectionToken 。
想知道
useExisting
跟useValue
、useClass
與useFactory
有哪裡不一樣的,也可以參考 Mike 的 [Angular 大師之路] Day 20 - 在 @NgModule 的 providers: [] 自由更換注入內容 (1) 與 [Angular 大師之路] Day 21 - 在 @NgModule 的 providers: [] 自由更換注入內容 (2) 。而
forwardRef()
的部份,我覺得官網的 Dependency injection in action - Break circularities with a forward class reference 講得比較清楚。最後的
multi: true
,可以參考林穎平 EP 的 [Day 8] 所以我說那個 multi 是? ,如果想要更深入的了解其原理,他也寫了一篇 [Day 10] 深度看一下 Angular 建立 multi provider 的機制(真的很深入)
至此,我們就可以儲存檔案來看一下初始化完後會印出的 Log :
接著我們在使用 DateRangeComponent
的 Component 裡加上以下程式碼以觀察其運作結果:
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
dateRange: ''
});
setTimeout(() => {
console.log('---- 3秒後 ----');
this.formGroup?.setValue({ dateRange: 'Leo' });
this.formGroup?.disable();
}, 3000);
}
然後我們會發現:
這樣大家有比較了解一開始關於 ControlValueAccessor
各函式的說明了嗎?
如果用圖示的話,現在的結構大概像這樣:
如果我們設值給 FormControl
時,則會觸發 ControlValueAccessor
的函式 writeValue
:
如果我們 disable
或 enable
了該 FormControl
,則會觸發 ControlValueAccessor
的函式 setDisabledState
:
而如果使用者改動了自訂的表單元件的值,則我們自訂的表單元件應該要呼叫透過初始化時所觸發的 registerOnChange
所傳入的 fn
去通知 FormControl
:
讀萬卷書不如行萬里路。接下來,我們把剩下的實作做完就會更了解這其中的運作流程了!
首先,先加工一下使用 DateRangeComponent
的 Component :
export class ReactiveFormsDateRangeComponent implements OnInit {
formGroup: FormGroup | undefined;
constructor(private formBuilder: FormBuilder) { }
ngOnInit(): void {
const date = new Date();
this.formGroup = this.formBuilder.group({
dateRange: `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
});
}
enable(): void {
this.formGroup?.enable();
}
disable(): void {
this.formGroup?.disable();
}
}
Template 的部份也加工一下:
<form *ngIf="formGroup" [formGroup]="formGroup">
<app-date-range formControlName="dateRange"></app-date-range>
<p>
<button type="button" [disabled]="formGroup.disabled" (click)="disable()">DISABLE</button>
<button type="button" [disabled]="formGroup.enabled" (click)="enable()">ENABLE</button>
</p>
</form>
<pre>{{ formGroup?.getRawValue() | json }}</pre>
然後把 DateRangeComponent
改成這樣:
export class DateRangeComponent implements OnInit, ControlValueAccessor {
formGroup: FormGroup | undefined;
fnFormRegisterOnChange: ((dateString: string) => void) | undefined;
fnFormRegisterOnTouched: (() => void) | undefined;
constructor(private formBuilder: FormBuilder) { }
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
startDate: ['', [Validators.required, Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]],
endDate: ['', Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]
}, { validators: dateRangeValidator });
this.formGroup.valueChanges.subscribe(({ startDate, endDate }) => {
let dateString = startDate;
if (endDate) {
dateString += `, ${endDate}`;
}
if (this.formGroup?.errors) {
dateString = '';
}
if (this.fnFormRegisterOnChange) {
this.fnFormRegisterOnChange(dateString);
}
});
}
writeValue(dateRangeString: string): void {
const [startDate, endDate] = dateRangeString.split(', ');
this.formGroup?.patchValue({ startDate, endDate }, {
emitEvent: false
});
}
registerOnChange(fn: (dateRangeString: string) => void): void {
this.fnFormRegisterOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.fnFormRegisterOnTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.formGroup?.disable();
} else {
this.formGroup?.enable();
}
}
}
結果:
對了,這樣的作法不僅僅只適用於 Reactive Forms 噢!大家可以在使用
DateRangeComponent
的時候用 Template Driven Forms 的方式試試看,也是行得通的唷!
今天的實作練習應該滿好玩的吧?
我能理解大家第一次碰到的時候都會比較難以理解,記得我第一次碰到的時候,也只是複製人家的程式碼然後貼上而已,根本就不是了解其運作原理。
因此,希望我今天的文章能讓大家可以不僅僅只是複製貼上,而是對於其流程與原理有所掌握與理解。
今天的程式碼會放在 Github - Branch: day28 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,還請麻煩留言給我讓我知道!
Hi Leo,
後面幾篇真的有點難,蠻多設定的,看了好久才有點看懂~~>_<~~這篇的範例我在測試時發現了一個奇怪的地方,那就是一開始DateRangeComponent
在writeValue
完,其欄位不會檢查輸入值的正確性(如圖)
示意圖:
作者撰寫文章時剛好當天日期不會報錯,所以可能沒發現這個奇怪的點XD
不知道是否有辦法讓他一開始就能辨識出錯誤的日期?已經在嘗試將writeValue
中的emitEvent
改成true
去觸發valueChanges
,但還是沒有用QQ
Ps.把writeValue完的GroupControl errors印出來有看到startDate的錯誤
示意圖:
還有一個很前面就想問的菜鳥問題~那就是在DateRangeComponent裡的ngOnInit有subscribe this.formGroup.valueChanges,那麼我們在寫的時候要在ngOnDestroy寫上unsubscribe this.formGroup.valueChanges嗎?因為好像記得subscribe東西要記得unsubscribe,但都沒看到筆者寫?
2022/3/11 18:00更:
第一個問題已經知道解決辦法了XD原來只要在writeValue
中再呼叫markAsDirty
的函式就可以順利讓錯誤出現了